딥링크와 유니버설 링크의 차이
딥링크와 유니버설 링크의 차이
딥링크는 앱의 특정 목적지로 이동시키는 개념 전체를 가리킨다. sampleapp:// 같은 custom scheme은 간단하지만 domain ownership 검증과 자연스러운 웹 fallback이 약하다. iOS Universal Link와 Android App Link는 HTTPS domain이 앱을 허용한다는 association 파일과 앱 설정을 양쪽에서 검증한다. 앱이 없으면 같은 URL을 웹에서 열 수 있다. 어떤 링크이든 URL parameter는 신뢰할 수 없는 입력이며, 앱에서는 삭제·결제 같은 부수효과를 직접 실행하지 않고 인증과 권한 확인 뒤 내부 AppDestination으로 변환해야 한다.
목차
- #딥링크는 하나의 기술 이름이 아니다
- #Custom URL Scheme의 장점과 한계
- #Universal Link와 App Link가 검증하는 것
- #세 가지 방식을 비교하기
- #하나의 HTTPS URL을 앱과 웹이 함께 소유하기
- #iOS AASA 파일과 Associated Domains 설정
- #Android Asset Links와 Intent Filter 설정
- #앱에서는 URL을 목적지 타입으로 변환하기
- #Cold Start와 실행 중 Link를 같은 Pipeline으로 처리하기
- #로그인이 필요하면 원래 목적지를 안전하게 보관하기
- #링크가 직접 부수효과를 실행하지 않게 하기
- #Redirect와 Tracking Parameter 다루기
- #설치되지 않은 경우와 Deferred Deep Link 구분하기
- #플랫폼별 검증과 Cache 문제 진단하기
- #테스트해야 할 링크 행렬
- #운영 가능한 링크 계약 만들기
- #구현 체크리스트
- #마무리
- #관련 노트
- #참고 자료
딥링크는 하나의 기술 이름이 아니다
“딥링크를 적용한다”는 말에는 서로 다른 방식이 섞여 있다.
Custom scheme
sampleapp://entries/42
HTTPS verified link
https://app.example.invalid/entries/42
둘 다 앱 안의 상세 화면으로 이어질 수 있으므로 넓은 의미에서는 딥링크다. 하지만 운영체제가 링크와 앱의 관계를 검증하는 방식, 앱이 없을 때 동작, 다른 앱의 가로채기 가능성이 다르다.
용어를 다음처럼 구분하면 대화가 명확해진다.
- Deep link: 앱의 특정 콘텐츠나 행동으로 이동시키는 개념
- Custom URL Scheme: 앱이 등록한 임의 scheme으로 여는 링크
- Universal Link: Apple 플랫폼에서 검증된 HTTPS link
- Android App Link: Android에서 검증된 HTTPS link
제품 콘텐츠 URL은 HTTPS verified link를 기본으로 하고, custom scheme은 OAuth callback처럼 별도 이유가 있는 제한된 용도에서 검토한다.
이 글의 domain, bundle ID, package name, fingerprint는 모두 실제 서비스와 무관한 가상 값이다.
Custom URL Scheme의 장점과 한계
Custom scheme은 서버 association 파일 없이 앱에 scheme을 등록해 사용할 수 있다.
sampleapp://entries/entry-demo-42
장점:
- 구현과 로컬 실험이 빠르다.
- HTTPS domain이 없어도 된다.
- 특정 SDK callback 형식과 맞을 수 있다.
한계:
- 다른 앱이 같은 scheme을 등록할 수 있다.
- 운영체제가 domain ownership과 앱 관계를 검증하지 않는다.
- 앱이 없으면 일반 웹 콘텐츠로 자연스럽게 이어지지 않는다.
- 메신저·브라우저가 unknown scheme을 차단하거나 경고할 수 있다.
- URL을 그대로 analytics와 로그에 남기는 위험은 동일하다.
Custom scheme callback에 authorization code나 token이 포함되면 악성 앱이 scheme을 선점해 가로챌 위험을 threat model에 넣어야 한다. OAuth native app에서는 PKCE와 claimed HTTPS URI 등 표준 권고를 따른다. OAuth state와 PKCE가 막아주는 공격과 연결되는 지점이다.
Custom scheme을 쓴다고 다음과 같은 강제 fallback URL을 만들지 않는다.
<script>
location.href = "sampleapp://entries/42";
setTimeout(() => {
location.href = "https://store.example.invalid/app";
}, 800);
</script>
앱이 열렸는지 정확히 알기 어렵고 browser 정책과 timing에 따라 app store가 함께 열릴 수 있다.
Universal Link와 App Link가 검증하는 것
검증된 HTTPS link는 앱과 website가 서로 관계를 선언한다.
flowchart LR
A[App setting] -->|handles domain| C[OS verification]
B[Website association file] -->|allows app identity| C
C --> D{verified?}
D -- Yes and installed --> E[Open app]
D -- No app --> F[Open web]
D -- Verification failed --> FiOS:
- 앱 entitlement에
applinks:domain - domain의
apple-app-site-association에 Team ID와 bundle ID
Android:
- manifest intent filter에 HTTPS host와
autoVerify - domain의
assetlinks.json에 package name과 signing certificate fingerprint
website를 제어하는 주체와 해당 서명 앱을 배포하는 주체의 양쪽 선언이 맞아야 한다. custom scheme의 “먼저 등록한 handler”와 다른 보안 모델이다.
검증은 URL 안의 resource가 사용자에게 허용됐다는 뜻은 아니다. domain-app association만 확인한다. /entries/42의 권한은 서버가 현재 session으로 다시 검사해야 한다.
세 가지 방식을 비교하기
| 항목 | Custom Scheme | iOS Universal Link | Android App Link |
|---|---|---|---|
| URL 예 | sampleapp://entries/42 |
https://app.example/entries/42 |
동일 HTTPS |
| Domain 검증 | 없음 | AASA + entitlement | Asset Links + manifest |
| 앱 미설치 | 실패·별도 처리 | 웹 URL | 웹 URL |
| 다른 앱 가로채기 방어 | 약함 | 검증된 association | 검증된 association |
| 웹과 URL 공유 | 어려움 | 가능 | 가능 |
| 주요 설정 위치 | 앱 manifest·plist | 앱 + website | 앱 + website |
| 인증·권한 검증 | 여전히 필요 | 여전히 필요 | 여전히 필요 |
Universal Link와 App Link도 사용자가 platform 설정에서 연결 동작을 바꾸거나 검증이 실패하면 웹으로 열릴 수 있다. 항상 앱이 열린다고 가정하지 않는다.
하나의 HTTPS URL을 앱과 웹이 함께 소유하기
콘텐츠마다 canonical HTTPS URL을 정한다.
https://app.example.invalid/entries/entry-demo-42
앱 설치:
OS verification → 앱 → EntryDestination
앱 미설치 또는 association 실패:
브라우저 → 동일 entry web page 또는 login
앱 route 이름을 URL에 그대로 노출하기보다 web information architecture와 함께 설계한다.
공개 콘텐츠 /articles/:slug
인증 콘텐츠 /entries/:publicId
설정 /settings/notifications
웹 전용 /help/*
모든 path를 앱이 claim할 필요는 없다. 도움말이나 약관처럼 웹에서 계속 볼 콘텐츠는 association rule에서 제외할 수 있다.
URL의 ID는 enumeration 공격을 고려한 opaque public ID를 사용하더라도 server authorization이 필요하다.
iOS AASA 파일과 Associated Domains 설정
iOS website에는 extension이 없는 apple-app-site-association 파일을 HTTPS로 제공한다.
{
"applinks": {
"details": [
{
"appIDs": [
"TEAMDEMO12.dev.example.sample"
],
"components": [
{
"/": "/entries/*",
"comment": "Opens entry destinations"
},
{
"/": "/help/*",
"exclude": true,
"comment": "Keeps help content on the web"
}
]
}
]
}
}
대표 배치 경로:
https://app.example.invalid/.well-known/apple-app-site-association
Apple 공식 문서는 유효한 HTTPS certificate를 사용하고 association 파일 요청에 redirect를 두지 않도록 안내한다. 각 subdomain은 별도 domain으로 취급해 필요한 파일과 entitlement를 구성한다.
앱 target의 Associated Domains capability:
applinks:app.example.invalid
entitlement에는 path나 trailing slash를 넣지 않는다.
Flutter plugin이나 app delegate는 OS가 전달한 URL을 Dart의 link source로 전달한다. association 성공과 앱 내부 route parse는 서로 다른 단계다.
association 파일 수정이 기기에서 즉시 반영된다고 가정하지 않는다. Apple 관리 CDN과 설치 시점 검증이 개입하므로 서버 원본이 맞아도 기존 설치가 오래된 association을 사용할 수 있다.
Android Asset Links와 Intent Filter 설정
Android website에는 다음 경로로 Digital Asset Links 파일을 제공한다.
https://app.example.invalid/.well-known/assetlinks.json
가상 예:
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "dev.example.sample",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:00:11:DE:MO"
]
}
}
]
실제 fingerprint는 debug keystore 값을 예제처럼 넣지 않는다. Play App Signing을 사용하면 사용자의 기기에 설치되는 앱을 서명하는 certificate fingerprint를 Play Console에서 확인한다. 개발·production 변형이 모두 필요하다면 각 identity와 domain 정책을 명확히 분리한다.
manifest intent filter:
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="app.example.invalid"
android:pathPrefix="/entries/" />
</intent-filter>
너무 넓은 path를 manifest에서 claim한 뒤 앱 parser가 대부분을 거절하면 사용자 경험이 깨진다. website association과 manifest, 앱 route table의 허용 범위를 함께 관리한다.
앱에서는 URL을 목적지 타입으로 변환하기
OS가 전달한 URL을 Navigator.pushNamed(uri.path)로 실행하지 않는다. parser가 scheme, host, path segment, query allowlist를 검사한다.
sealed class AppDestination {
const AppDestination();
}
final class EntryDestination extends AppDestination {
const EntryDestination(this.entryId);
final String entryId;
}
final class NotificationSettingsDestination
extends AppDestination {
const NotificationSettingsDestination();
}
final class AppLinkParser {
AppDestination parse(Uri uri) {
if (uri.scheme != 'https' ||
uri.host != 'app.example.invalid' ||
uri.hasPort) {
throw const AppLinkFailure.untrustedOrigin();
}
final segments = uri.pathSegments;
if (segments.length == 2 &&
segments.first == 'entries') {
final id = parsePublicEntryId(segments[1]);
return EntryDestination(id);
}
if (segments.length == 2 &&
segments[0] == 'settings' &&
segments[1] == 'notifications') {
return const NotificationSettingsDestination();
}
throw const AppLinkFailure.unsupportedPath();
}
}
host suffix 검사만 하지 않는다.
// 위험: evil-example.invalid도 일치할 수 있다.
uri.host.endsWith('example.invalid');
subdomain을 허용한다면 정확한 set이나 registrable domain 규칙을 안전한 URL library로 구현한다. percent decoding 뒤 path traversal과 빈 segment도 확인한다.
Cold Start와 실행 중 Link를 같은 Pipeline으로 처리하기
앱이 종료된 상태에서 링크를 탭하면 router가 준비되기 전에 initial link가 도착할 수 있다. 실행 중 링크는 stream으로 들어올 수 있다.
abstract interface class AppLinkSource {
Future<Uri?> initialUri();
Stream<Uri> get uriEvents;
}
두 경로를 같은 coordinator로 합친다.
Future<void> start() async {
final initial = await source.initialUri();
if (initial != null) {
pendingLinks.offer(initial);
}
_subscription = source.uriEvents.listen(
pendingLinks.offer,
onError: recordLinkSourceFailure,
);
await appReadiness.whenReady;
await consumePendingLinks();
}
parser와 destination resolver는 FCM navigation과 공유할 수 있다.
flowchart LR
A[Universal or App Link] --> D[AppDestination]
B[FCM tap intent] --> D
C[In-app navigation] --> D
D --> E[Auth and resource resolver]
E --> F[Declarative router]FCM과 route pipeline을 공유하는 이유는 FCM 알림 탭과 앱 라우팅 연결하기에서 자세히 다뤘다.
로그인이 필요하면 원래 목적지를 안전하게 보관하기
비로그인 상태에서 /entries/42를 열면 login 뒤 목적지로 돌아가야 한다.
Future<void> open(AppDestination destination) async {
if (destinationRequiresAuth(destination) &&
!authState.isAuthenticated) {
postLoginDestination.save(
destination,
expiresAt: clock.now().add(
const Duration(minutes: 10),
),
);
router.go(const LoginDestination());
return;
}
await resolveAndNavigate(destination);
}
원본 URL 전체를 장기간 preferences에 저장하지 않는다. 검증된 typed destination만 짧은 TTL로 보관한다. query에 campaign·개인정보가 있을 수 있기 때문이다.
로그인한 계정이 링크 대상에 접근 가능한지는 server에서 확인한다. URL의 accountId나 role query를 권한 근거로 사용하지 않는다.
사용자가 login을 취소하거나 다른 계정으로 들어오면 pending destination을 폐기하거나 재검증한다.
링크가 직접 부수효과를 실행하지 않게 하기
Apple 공식 문서도 Universal Link parameter를 검증하고 링크가 사용자 데이터를 직접 삭제하거나 민감 정보에 곧바로 접근하는 행동을 피하라고 경고한다.
다음 링크를 열었다고 즉시 삭제하면 안 된다.
https://app.example.invalid/entries/42/delete
안전한 흐름:
sequenceDiagram
participant Link
participant App
participant API
participant User
Link->>App: delete intent URL
App->>API: resource와 권한 조회
API-->>App: current resource
App->>User: 삭제 확인 화면
User->>App: 명시적 확인
App->>API: authenticated delete결제 승인, 이메일 변경, 계정 연결도 링크 하나만으로 완료하지 않는다. nonce와 server-side state, 사용자 확인, 재인증을 사용한다.
Redirect와 Tracking Parameter 다루기
마케팅 link가 tracking domain을 거쳐 canonical app domain으로 redirect될 수 있다. platform association 검증과 user tap 동작에서 redirect chain이 예상대로 처리되는지 실제 기기에서 확인한다.
가능하면 공유하는 최종 URL 자체를 verified domain으로 사용한다.
좋음
https://app.example.invalid/entries/42?utm_source=message
복잡함
https://click.example.invalid/a1
→ https://app.example.invalid/entries/42
tracking parameter는 destination 의미와 분리한다.
final attribution = Attribution.fromAllowedQuery(
uri.queryParameters,
allowedKeys: const {
'utm_source',
'utm_medium',
'campaign_id',
},
);
알 수 없는 query를 내부 route 인자로 그대로 전달하지 않는다. attribution log에도 resource ID나 개인정보를 넣지 않는다.
Open redirect parameter는 특히 위험하다.
/open?next=https://evil.invalid
next를 허용해야 한다면 absolute URL을 받기보다 내부 destination key를 allowlist로 매핑한다.
설치되지 않은 경우와 Deferred Deep Link 구분하기
Verified HTTPS link의 기본 fallback은 앱이 없을 때 web page를 여는 것이다. 사용자가 그 web page에서 앱을 설치했다고 해서 설치 후 자동으로 이전 상세 목적지가 복원되는 것은 별도의 문제다.
- Web fallback: 앱이 없으면 같은 URL을 browser에서 표시
- Deferred deep link: 설치 과정을 거친 뒤 원래 intent 복원
Deferred deep link에는 attribution provider, app store 경유, privacy 정책, 만료·위조 방지가 추가로 필요하다. Universal Link나 App Link를 설정했다고 자동으로 완성되는 기능으로 설명하지 않는다.
웹 fallback 페이지는 앱 설치 banner만 보여 주고 콘텐츠를 전혀 제공하지 않는 것보다 가능한 범위에서 실제 내용을 제공하는 편이 링크의 본래 의미를 유지한다.
플랫폼별 검증과 Cache 문제 진단하기
iOS
확인 순서:
- entitlement에 정확한 domain이 있는가
- Team ID와 bundle ID가 AASA
appIDs와 일치하는가 - AASA가 HTTPS·무redirect로 제공되는가
- path component rule이 실제 URL을 포함하는가
- 새 설치와 기존 설치에서 각각 동작하는가
- Safari same-domain navigation 등 사용자 의도 규칙을 확인했는가
Apple은 최신 OS에서 associated domain 파일 전달에 관리 CDN을 사용한다. 배포 직후 서버 파일만 보고 기기가 즉시 새 rule을 받았다고 단정하지 않는다.
Android
확인 순서:
- intent filter에
VIEW,DEFAULT,BROWSABLE, HTTPS host가 있는가 android:autoVerify가 설정됐는가- assetlinks package name이 application ID와 일치하는가
- fingerprint가 실제 배포 signing certificate와 일치하는가
- domain verification 상태를 기기 명령으로 확인했는가
- 사용자의 link handling 설정도 확인했는가
adb shell pm get-app-links dev.example.sample
재검증 명령은 대상 OS의 공식 문서를 확인해 사용한다. 앱 삭제·재설치, verification reset은 test device 상태를 바꾸므로 개인 주기기보다 전용 기기에서 수행한다.
테스트해야 할 링크 행렬
| 조건 | 기대 결과 |
|---|---|
| 앱 설치 + association 성공 | 앱 detail |
| 앱 미설치 | web detail |
| association 실패 | web fallback |
| cold start | readiness 뒤 한 번 이동 |
| background 앱 | 현재 stack 정책에 맞게 이동 |
| 비로그인 | login 뒤 typed destination 복원 |
| 다른 계정 | server 권한 재검증 |
| 존재하지 않는 ID | 안전한 not-found 화면 |
| 권한 없는 ID | forbidden 안내 |
| malformed percent encoding | 링크 거절 |
| 외부 host·HTTP scheme | 앱 destination 거절 |
| destructive action path | 확인 화면까지만 이동 |
| 같은 link 연속 두 번 | 중복 route 방지 |
| redirect tracking link | platform별 실제 동작 확인 |
source도 다양하게 시험한다.
- Messages와 메신저
- Safari·Chrome
- QR scanner
- Notes
- 다른 앱의
openURL - FCM notification
한 browser에서 성공했다고 platform association 전체가 맞는 것은 아니다.
운영 가능한 링크 계약 만들기
link route도 API처럼 version과 폐기 정책이 필요하다.
/entries/:publicId stable
/v2/share/:shareToken limited lifetime
/legacy/records/:id web redirect or app migration
앱 구버전이 새 path를 받을 수 있으므로 server와 앱의 rollout 순서를 정한다. 새 URL을 발송하기 전에 최소 지원 앱이 처리하거나 web fallback이 안전해야 한다.
관측 event:
app_link_received source=universal path_key=entry-detail
app_link_parse result=unsupported_path
app_link_resolve result=forbidden
app_link_navigation result=deduplicated
전체 URL과 query, resource ID는 남기지 않는다. route template과 오류 category를 사용한다.
구현 체크리스트
마무리
딥링크는 앱의 특정 위치로 이동하는 전체 개념이고, custom scheme과 Universal Link·App Link는 이를 구현하는 서로 다른 방법이다. Custom scheme은 간단하지만 앱과 domain의 소유 관계를 검증하지 않고 웹 fallback도 약하다.
Universal Link와 Android App Link는 앱 설정과 website association 파일을 양쪽에서 확인해 HTTPS URL을 앱과 연결한다. 앱이 없거나 검증이 실패하면 같은 URL을 웹에서 열 수 있다. 그러나 이 검증은 사용자에게 resource 권한이 있다는 의미는 아니다.
앱은 들어온 URL을 신뢰하지 않고 scheme·host·path·parameter를 검사해 작은 AppDestination으로 변환한다. 초기화와 인증 뒤 서버에서 resource를 확인하고 route를 연다. 링크는 삭제나 결제 같은 부수효과가 아니라 확인 가능한 화면까지만 연결한다.
좋은 딥링크는 앱을 여는 데서 끝나지 않는다. 앱·웹·서버가 하나의 콘텐츠 주소를 공유하고, 설치 여부와 앱 상태가 달라도 안전한 목적지로 이어지는 계약이다.
관련 노트
- FCM 알림 탭과 앱 라우팅 연결하기
- Flutter Navigator와 선언형 라우팅 비교
- BuildContext를 비동기 구간 뒤에 사용할 때 주의점
- Access Token과 Refresh Token의 역할 분리
- OAuth state와 PKCE가 막아주는 공격
참고 자료
- Apple Developer — Allowing apps and websites to link to your content
- Apple Developer — Supporting associated domains
- Apple Developer — Supporting universal links in your app
- Apple Developer — Debugging universal links
- Android Developers — About App Links
- Android Developers — Configure website associations
- Android Developers — Verify App Links